agora inbox for pgsql-hackers@postgresql.org  
help / color / mirror / Atom feed
[PATCH v29 04/11] Add Incremental View Maintenance support to pg_dump
1285+ messages / 2 participants
[nested] [flat]

* [PATCH v29 04/11] Add Incremental View Maintenance support to pg_dump
@ 2020-11-11 08:01 Yugo Nagata <nagata@sraoss.co.jp>
  0 siblings, 0 replies; 1285+ messages in thread

From: Yugo Nagata @ 2020-11-11 08:01 UTC (permalink / raw)

Support CREATE INCREMENTAL MATERIALIZED VIEW syntax.
---
 src/bin/pg_dump/pg_dump.c        | 18 +++++++++++++++---
 src/bin/pg_dump/pg_dump.h        |  1 +
 src/bin/pg_dump/t/002_pg_dump.pl | 15 +++++++++++++++
 3 files changed, 31 insertions(+), 3 deletions(-)

diff --git a/src/bin/pg_dump/pg_dump.c b/src/bin/pg_dump/pg_dump.c
index 65f64c282d..013ead7655 100644
--- a/src/bin/pg_dump/pg_dump.c
+++ b/src/bin/pg_dump/pg_dump.c
@@ -6354,6 +6354,7 @@ getTables(Archive *fout, int *numTables)
 	int			i_relacl;
 	int			i_acldefault;
 	int			i_ispartition;
+	int			i_isivm;
 
 	/*
 	 * Find all the tables and table-like objects.
@@ -6456,10 +6457,17 @@ getTables(Archive *fout, int *numTables)
 
 	if (fout->remoteVersion >= 100000)
 		appendPQExpBufferStr(query,
-							 "c.relispartition AS ispartition ");
+							 "c.relispartition AS ispartition, ");
 	else
 		appendPQExpBufferStr(query,
-							 "false AS ispartition ");
+							 "false AS ispartition, ");
+
+	if (fout->remoteVersion >= 170000)
+		appendPQExpBufferStr(query,
+							 "c.relisivm AS isivm ");
+	else
+		appendPQExpBufferStr(query,
+							 "false AS isivm ");
 
 	/*
 	 * Left join to pg_depend to pick up dependency info linking sequences to
@@ -6568,6 +6576,7 @@ getTables(Archive *fout, int *numTables)
 	i_relacl = PQfnumber(res, "relacl");
 	i_acldefault = PQfnumber(res, "acldefault");
 	i_ispartition = PQfnumber(res, "ispartition");
+	i_isivm = PQfnumber(res, "isivm");
 
 	if (dopt->lockWaitTimeout)
 	{
@@ -6647,6 +6656,7 @@ getTables(Archive *fout, int *numTables)
 			tblinfo[i].amname = pg_strdup(PQgetvalue(res, i, i_amname));
 		tblinfo[i].is_identity_sequence = (strcmp(PQgetvalue(res, i, i_is_identity_sequence), "t") == 0);
 		tblinfo[i].ispartition = (strcmp(PQgetvalue(res, i, i_ispartition), "t") == 0);
+		tblinfo[i].isivm = (strcmp(PQgetvalue(res, i, i_isivm), "t") == 0);
 
 		/* other fields were zeroed above */
 
@@ -15737,9 +15747,11 @@ dumpTableSchema(Archive *fout, const TableInfo *tbinfo)
 			binary_upgrade_set_pg_class_oids(fout, q,
 											 tbinfo->dobj.catId.oid, false);
 
-		appendPQExpBuffer(q, "CREATE %s%s %s",
+		appendPQExpBuffer(q, "CREATE %s%s%s %s",
 						  tbinfo->relpersistence == RELPERSISTENCE_UNLOGGED ?
 						  "UNLOGGED " : "",
+						  tbinfo->relkind == RELKIND_MATVIEW && tbinfo->isivm ?
+						  "INCREMENTAL " : "",
 						  reltypename,
 						  qualrelname);
 
diff --git a/src/bin/pg_dump/pg_dump.h b/src/bin/pg_dump/pg_dump.h
index 9036b13f6a..3705891d25 100644
--- a/src/bin/pg_dump/pg_dump.h
+++ b/src/bin/pg_dump/pg_dump.h
@@ -323,6 +323,7 @@ typedef struct _tableInfo
 
 	int			numParents;		/* number of (immediate) parent tables */
 	struct _tableInfo **parents;	/* TableInfos of immediate parents */
+	bool		isivm;			/* is incrementally maintainable materialized view? */
 
 	/*
 	 * These fields are computed only if we decide the table is interesting
diff --git a/src/bin/pg_dump/t/002_pg_dump.pl b/src/bin/pg_dump/t/002_pg_dump.pl
index 0758fe5ea0..fb2f31e191 100644
--- a/src/bin/pg_dump/t/002_pg_dump.pl
+++ b/src/bin/pg_dump/t/002_pg_dump.pl
@@ -2799,6 +2799,21 @@ my %tests = (
 		},
 	},
 
+	'CREATE MATERIALIZED VIEW matview_ivm' => {
+		create_order => 21,
+		create_sql   => 'CREATE INCREMENTAL MATERIALIZED VIEW dump_test.matview_ivm (col1) AS
+					   SELECT col1 FROM dump_test.test_table;',
+		regexp => qr/^
+			\QCREATE INCREMENTAL MATERIALIZED VIEW dump_test.matview_ivm AS\E
+			\n\s+\QSELECT test_table.col1\E
+			\n\s+\QFROM dump_test.test_table\E
+			\n\s+\QWITH NO DATA;\E
+			/xm,
+		like =>
+		  { %full_runs, %dump_test_schema_runs, section_pre_data => 1, },
+		unlike => { exclude_dump_test_schema => 1, },
+	},
+
 	'CREATE POLICY p1 ON test_table' => {
 		create_order => 22,
 		create_sql => 'CREATE POLICY p1 ON dump_test.test_table
-- 
2.25.1


--Multipart=_Mon__28_Aug_2023_11_52_52_+0900_hj6L5h176QaSGtg7
Content-Type: text/x-diff;
 name="v29-0005-Add-Incremental-View-Maintenance-support-to-psql.patch"
Content-Disposition: attachment;
 filename="v29-0005-Add-Incremental-View-Maintenance-support-to-psql.patch"
Content-Transfer-Encoding: 7bit



^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v5 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index df8ce44ede6..63423eb0aed 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -130,3 +130,4 @@ test: for-portion-of
 test: ddl-dependency-locking
 test: pub-concurrent-drop
 test: drop-owned-grant
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--utPq7iwOpsnMO8LB--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v5 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index df8ce44ede6..63423eb0aed 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -130,3 +130,4 @@ test: for-portion-of
 test: ddl-dependency-locking
 test: pub-concurrent-drop
 test: drop-owned-grant
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--utPq7iwOpsnMO8LB--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v5 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index df8ce44ede6..63423eb0aed 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -130,3 +130,4 @@ test: for-portion-of
 test: ddl-dependency-locking
 test: pub-concurrent-drop
 test: drop-owned-grant
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--utPq7iwOpsnMO8LB--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v5 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index df8ce44ede6..63423eb0aed 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -130,3 +130,4 @@ test: for-portion-of
 test: ddl-dependency-locking
 test: pub-concurrent-drop
 test: drop-owned-grant
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--utPq7iwOpsnMO8LB--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v5 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index df8ce44ede6..63423eb0aed 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -130,3 +130,4 @@ test: for-portion-of
 test: ddl-dependency-locking
 test: pub-concurrent-drop
 test: drop-owned-grant
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--utPq7iwOpsnMO8LB--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v5 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index df8ce44ede6..63423eb0aed 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -130,3 +130,4 @@ test: for-portion-of
 test: ddl-dependency-locking
 test: pub-concurrent-drop
 test: drop-owned-grant
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--utPq7iwOpsnMO8LB--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v5 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index df8ce44ede6..63423eb0aed 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -130,3 +130,4 @@ test: for-portion-of
 test: ddl-dependency-locking
 test: pub-concurrent-drop
 test: drop-owned-grant
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--utPq7iwOpsnMO8LB--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v5 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index df8ce44ede6..63423eb0aed 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -130,3 +130,4 @@ test: for-portion-of
 test: ddl-dependency-locking
 test: pub-concurrent-drop
 test: drop-owned-grant
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--utPq7iwOpsnMO8LB--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v5 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index df8ce44ede6..63423eb0aed 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -130,3 +130,4 @@ test: for-portion-of
 test: ddl-dependency-locking
 test: pub-concurrent-drop
 test: drop-owned-grant
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--utPq7iwOpsnMO8LB--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v5 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index df8ce44ede6..63423eb0aed 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -130,3 +130,4 @@ test: for-portion-of
 test: ddl-dependency-locking
 test: pub-concurrent-drop
 test: drop-owned-grant
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--utPq7iwOpsnMO8LB--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v5 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index df8ce44ede6..63423eb0aed 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -130,3 +130,4 @@ test: for-portion-of
 test: ddl-dependency-locking
 test: pub-concurrent-drop
 test: drop-owned-grant
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--utPq7iwOpsnMO8LB--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 20 +++-
 .../expected/role-membership-drop-member.out  | 97 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 63 ++++++++++++
 4 files changed, 180 insertions(+), 1 deletion(-)
  11.5% src/backend/commands/
  49.2% src/test/isolation/expected/
  38.6% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..ab3cfb0b13f 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role with OID %u does not exist", roleid)));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..7daf48395ca
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,97 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
+step s1_begin: BEGIN;
+step s1_set_role: SET ROLE regress_role_member_cu;
+step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER;
+step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member_cu: <... completed>
+step s1_reset: RESET ROLE;
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..054326ac535
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,63 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+	CREATE ROLE regress_role_group_cu;
+	CREATE ROLE regress_role_member_cu;
+	GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+	DROP ROLE IF EXISTS regress_role_member_cu;
+	DROP ROLE IF EXISTS regress_role_group_cu;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_set_role	{ SET ROLE regress_role_member_cu; }
+step s1_grant_cu	{ GRANT regress_role_group_cu TO CURRENT_USER; }
+step s1_commit		{ COMMIT; }
+step s1_reset		{ RESET ROLE; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_drop_member_cu	{ DROP ROLE regress_role_member_cu; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member and concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER and concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member and concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role and concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
+
+# GRANT role TO CURRENT_USER and concurrent DROP of the current user
+permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans
-- 
2.34.1


--fotX2lJRknAHpfH+--






^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by:
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 13 +++++-
 .../expected/role-membership-drop-member.out  | 36 ++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 43 +++++++++++++++++++
 4 files changed, 92 insertions(+), 1 deletion(-)
  13.8% src/backend/commands/
  42.9% src/test/isolation/expected/
  42.2% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..05ec753f4f0 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+		}
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..e6cae59d2c9
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,36 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..e0140826e52
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,43 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members
+# entries.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit
+
+# ALTER ROLE ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--cOHldBwZEf1OqVbN--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread

* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP
@ 2026-07-06 08:28 Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
  0 siblings, 0 replies; 1285+ messages in thread

From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw)

roleSpecsToIds() resolves role names to OIDs without acquiring any lock.
A concurrent DROP ROLE that commits between this resolution and the caller's use
of the OID leaves the caller operating on a stale OID, which can create orphaned
pg_auth_members entries.

Fix this by acquiring AccessShareLock on each resolved role within
roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using
its OID.

The AccessShareLock is held until end of transaction, so an open transaction
that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a
concurrent DROP ROLE on the same role until it commits.

Note that this introduces a potential deadlock between GRANT and DROP ROLE
when both target overlapping roles. The deadlock is detected and one session is
aborted with an error, which is preferable to the pre-patch behavior of silently
creating orphaned catalog entries.

Author: Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
Reported-by: Virender Singla <virender.cse@gmail.com>
Reviewed-by: Surya Poondla <suryapoondla4@gmail.com>
Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg
Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com
---
 src/backend/commands/user.c                   | 21 +++++-
 .../expected/role-membership-drop-member.out  | 75 +++++++++++++++++++
 src/test/isolation/isolation_schedule         |  1 +
 .../specs/role-membership-drop-member.spec    | 51 +++++++++++++
 4 files changed, 147 insertions(+), 1 deletion(-)
  14.9% src/backend/commands/
  48.1% src/test/isolation/expected/
  36.1% src/test/isolation/specs/

diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c
index 5b869e91c17..375fb527af2 100644
--- a/src/backend/commands/user.c
+++ b/src/backend/commands/user.c
@@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt)
  * roleSpecsToIds
  *
  * Given a list of RoleSpecs, generate a list of role OIDs in the same order.
+ * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE
+ * from removing it between resolution and the caller's catalog update.
  *
  * ROLESPEC_PUBLIC is not allowed.
  */
@@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames)
 		RoleSpec   *rolespec = lfirst_node(RoleSpec, l);
 		Oid			roleid;
 
-		roleid = get_rolespec_oid(rolespec, false);
+		if (rolespec->roletype == ROLESPEC_CSTRING)
+			roleid = RoleNameGetOid(rolespec->rolename,
+									AccessShareLock, false,
+									NULL, NULL);
+		else
+		{
+			roleid = get_rolespec_oid(rolespec, false);
+			LockSharedObject(AuthIdRelationId, roleid, 0,
+							 AccessShareLock);
+
+			/* Recheck that the role still exists after locking. */
+			if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid)))
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("role \"%s\" does not exist",
+								get_rolespec_name(rolespec))));
+		}
+
 		result = lappend_oid(result, roleid);
 	}
 	return result;
diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out
new file mode 100644
index 00000000000..5b267ea32c2
--- /dev/null
+++ b/src/test/isolation/expected/role-membership-drop-member.out
@@ -0,0 +1,75 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_grant: GRANT regress_role_group TO regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+step s1_begin: BEGIN;
+step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+step s2_check_orphans: 
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+
+count
+-----
+    0
+(1 row)
+
+
+starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_drop_owned: DROP OWNED BY regress_role_member;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
+
+starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit
+step s1_begin: BEGIN;
+step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group;
+step s2_drop_member: DROP ROLE regress_role_member; <waiting ...>
+step s1_commit: COMMIT;
+step s2_drop_member: <... completed>
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index b8ebe92553c..8fb8b52b77f 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,3 +128,4 @@ test: matview-write-skew
 test: lock-nowait
 test: for-portion-of
 test: ddl-dependency-locking
+test: role-membership-drop-member
diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec
new file mode 100644
index 00000000000..989fe996249
--- /dev/null
+++ b/src/test/isolation/specs/role-membership-drop-member.spec
@@ -0,0 +1,51 @@
+# Test that role membership commands properly lock the grantee/member
+# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members
+# entries, or from operating on a stale OID.
+
+setup
+{
+	CREATE ROLE regress_role_group;
+	CREATE ROLE regress_role_member;
+}
+
+teardown
+{
+	DROP ROLE IF EXISTS regress_role_group;
+	DROP ROLE IF EXISTS regress_role_member;
+	DROP ROLE IF EXISTS regress_role_new;
+}
+
+session s1
+step s1_begin		{ BEGIN; }
+step s1_grant		{ GRANT regress_role_group TO regress_role_member; }
+step s1_alter_add	{ ALTER GROUP regress_role_group ADD USER regress_role_member; }
+step s1_create_role	{ CREATE ROLE regress_role_new ROLE regress_role_member; }
+step s1_drop_owned	{ DROP OWNED BY regress_role_member; }
+step s1_reassign_owned	{ REASSIGN OWNED BY regress_role_member TO regress_role_group; }
+step s1_commit		{ COMMIT; }
+
+session s2
+step s2_drop_member	{ DROP ROLE regress_role_member; }
+step s2_check_orphans	{
+	SELECT count(*)
+	FROM pg_auth_members m
+	LEFT JOIN pg_authid ra ON m.roleid  = ra.oid
+	LEFT JOIN pg_authid me ON m.member  = me.oid
+	LEFT JOIN pg_authid gr ON m.grantor = gr.oid
+	WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL;
+}
+
+# GRANT role TO member - concurrent DROP of the member
+permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans
+
+# ALTER GROUP ADD USER - concurrent DROP of the member
+permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans
+
+# CREATE ROLE ... ROLE member - concurrent DROP of the member
+permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans
+
+# DROP OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_drop_owned s2_drop_member s1_commit
+
+# REASSIGN OWNED BY role - concurrent DROP of the role
+permutation s1_begin s1_reassign_owned s2_drop_member s1_commit
-- 
2.34.1


--Dvg/WemKV0I3T/Od--





^ permalink  raw  reply  [nested|flat] 1285+ messages in thread


end of thread, other threads:[~2026-07-06 08:28 UTC | newest]

Thread overview: 1285+ messages (download: mbox mbox.gz follow: Atom feed)
-- links below jump to the message on this page --
2020-11-11 08:01 [PATCH v29 04/11] Add Incremental View Maintenance support to pg_dump Yugo Nagata <nagata@sraoss.co.jp>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v5 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v5 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v5 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v5 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v5 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v5 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v5 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v5 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v5 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v5 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v5 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>
2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <bertranddrouvot.pg@gmail.com>

This inbox is served by agora; see mirroring instructions
for how to clone and mirror all data and code used for this inbox